47 — 开场与加载进度
引言
到目前为止,我们页面上只是一个 WebGL canvas,当内容准备就绪后就会显示出来。
在本课中,我们将学习如何添加一个非常简单的 loader,它由一根进度条组成,会在 assets 加载时逐渐填满。整个 scene 将是黑色的,只有在所有内容加载完成后才会伴随着优雅的淡入效果显示出来。
对于 loader,我们将使用 HTML 和 CSS。这是一个绝佳的机会,来看看如何将 HTML 与 WebGL 结合起来。
准备工作
我们的初始项目包含我们在 Realistic Render 课程中对 Flight Helmet 所做的内容。
覆盖层
首先,我们需要一种让 scene 淡出的方式。有很多方法可以做到这一点。我们可以对 <canvas> 的 CSS opacity 进行动画。我们也可以在 <canvas> 上方放一个黑色的 <div>,并对其 CSS opacity 进行动画。但相反,我们要把所有内容都保留在 WebGL 内部,绘制一个覆盖整个渲染的黑色矩形,并在需要时将它淡出。
问题在于:我们如何在 camera 前方绘制一个矩形?以我们目前掌握的知识,我们可以创建一个 plane 并把它直接放进 camera 而不是 scene 中,这应该能正常工作,因为 camera 继承自 Object3D,但这看起来有点像打补丁。
相反,我们将绘制一个不遵循位置、透视和投影规则的 plane,这样它就会被直接绘制在视图前方。别担心;这比你想象的要简单。
基础 plane
首先,我们将从一个经典的 plane 开始。
创建一个 PlaneGeometry、一个 MeshBasicMaterial 和一个 Mesh。然后把它们全部添加到 scene 中:
/**
* Overlay
*/
const overlayGeometry = new THREE.PlaneGeometry(1, 1, 1, 1)
const overlayMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 })
const overlay = new THREE.Mesh(overlayGeometry, overlayMaterial)
scene.add(overlay)
这个 plane 应该能从头盔的另一侧被看到。
填满渲染
我们现在希望这个 plane 始终位于 camera 前方。我们希望无论 camera 位置如何,它都能填满渲染。为此,我们将使用一个 ShaderMaterial。
将 MeshBasicMaterial 替换为一个 ShaderMaterial,并编写我们之前用 vertexShader 属性和 fragmentShader 属性学过的默认 shaders。你可以尝试凭记忆来完成,但即使做不到也不要沮丧。这需要时间:
const overlayMaterial = new THREE.ShaderMaterial({
vertexShader: `
void main()
{
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
void main()
{
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`
})
你应该会得到相同的结果,但这一次,我们可以控制 shaders 了。
要让 plane 填满渲染,我们需要不应用那些矩阵:
const overlayMaterial = new THREE.ShaderMaterial({
vertexShader: `
void main()
{
gl_Position = vec4(position, 1.0);
}
`,
fragmentShader: `
void main()
{
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`
})
现在你会得到一个位于中央的大矩形。它的位置、camera 位置、视场角或其他任何东西都无法改变它,因为没有任何矩阵被使用。
plane 顶点的坐标从 -0.5 到 +0.5,因为我们的 plane 尺寸为 1。
这样剥离后的 vertex shader,会按照指定的坐标直接在屏幕上绘制三角形,不考虑其他任何东西。我们可以通过将 wireframe 属性设置为 true 来看到这些三角形:
const overlayMaterial = new THREE.ShaderMaterial({
wireframe: true,
// ...
})
注释掉或删除 wireframe。
要获得一个更大的矩形,我们需要坐标从 -1 到 +1。要做到这一点,将 PlaneGeometry 的尺寸翻倍:
const overlayGeometry = new THREE.PlaneGeometry(2, 2, 1, 1)
这个矩形现在填满了整个渲染。
颜色与 alpha
假设我们不要这个红色,而是要黑色。
修改 gl_FragColor:
const overlayMaterial = new THREE.ShaderMaterial({
// ...
fragmentShader: `
void main()
{
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
}
`
})
一切看起来都是黑色的。
现在,我们希望能够控制 alpha。如你所知,它就是 gl_FragColor 的第四个值。
将这个第四个参数设置为 0.5,看看是否生效:
const overlayMaterial = new THREE.ShaderMaterial({
// ...
fragmentShader: `
void main()
{
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.5);
}
`
})
遗憾的是,一切仍然是完全黑色的,这是因为我们忘了一件很容易忘记的事。我们需要将 ShaderMaterial 上的 transparent 属性设置为 true:
const overlayMaterial = new THREE.ShaderMaterial({
transparent: true,
// ...
})
整个 scene 应该看起来更暗了。
Uniform
现在我们已经设置好了 overlay,我们需要一种控制 alpha 值的方法。我们将使用一个 uniform。
像之前一样添加一个 uAlpha uniform:
const overlayMaterial = new THREE.ShaderMaterial({
// ...
uniforms:
{
uAlpha: { value: 0.5 }
},
// ...
}) 然后在 fragmentShader 中使用它,替代原始的 0.5:
const overlayMaterial = new THREE.ShaderMaterial({
// ...
fragmentShader: `
uniform float uAlpha;
void main()
{
gl_FragColor = vec4(0.0, 0.0, 0.0, uAlpha);
}
`
})
你应该会得到相同的结果,但这一次我们可以通过 JavaScript 直接控制 alpha,使用 uAlpha uniform。
让我们将该 uniform 的值改为 1,以便从全黑的屏幕开始。
const overlayMaterial = new THREE.ShaderMaterial({
// ...
uniforms:
{
uAlpha: { value: 0.5 }
},
// ...
})
加载
现在我们的 overlay 已经准备好被动画,我们想知道所有内容何时加载完成。
虽然 scene 中只有一个模型,但我们实际上加载了许多 assets。我们加载了构成环境贴图的 6 张图片、模型的 geometries,以及模型中使用的所有 textures。
为了加载这些 assets,我们使用了 GLTFLoader 和一个 CubeTextureLoader。两者都可以接收一个 LoadingManager 作为参数。那个 LoadingManager ——正如我们在课程开头看到的那样——可以用来随时了解全局加载进度。
实例化一个 LoadingManager,并在 GLTFLoader 和 CubeTextureLoader 中使用它:
/**
* Loaders
*/
const loadingManager = new THREE.LoadingManager()
const gltfLoader = new GLTFLoader(loadingManager)
const cubeTextureLoader = new THREE.CubeTextureLoader(loadingManager) 应该没有任何变化,但我们现在可以向 LoadingManager 传入两个函数。
第一个函数会在所有内容加载完成时被触发,第二个函数会在加载进行时被触发。
添加这两个函数,并带有以下参数:
const loadingManager = new THREE.LoadingManager(
// Loaded
() =>
{
console.log('loaded')
},
// Progress
() =>
{
console.log('progress')
}
) 你应该会在日志中看到多个 "progress",并在最后看到一个 "loaded"。
progress 函数稍后会有用。目前,我们只需要 loaded 函数。
动画
要淡出 overlay,我们需要一种对 uAlpha uniform 值进行动画的方法。虽然这有点大材小用,但仅仅为此,我们将像课程开头那样使用 GSAP 库。
首先,在终端中,使用 npm install --save gsap@3.12 安装 gsap 库——如果你停止了服务器,请重新启动它。
现在 dependencies 中有了 gsap,我们可以导入它:
import { gsap } from 'gsap' 最后,我们可以在 loaded 函数中使用它来对 uAlpha uniform 值进行动画:
const loadingManager = new THREE.LoadingManager(
// Loaded
() =>
{
gsap.to(overlayMaterial.uniforms.uAlpha, { duration: 3, value: 0 })
},
// ...
)
所有内容加载完成后,overlay 应该会优雅地淡出。
加载条
这些 assets 相当大,整个项目大约有 50MB。这个模型可以做一些优化,但这不是本课的主题。这是一个绝佳的机会,来通过一个漂亮的加载条观察 assets 的加载过程。
模拟正常带宽
问题在于我们是在本地测试,所有东西加载得都飞快。如果你使用的是 Chrome,你可以模拟更慢的带宽。
以下说明针对 Chrome,但所有这些功能在 Firefox 中也都有。
在 Developer Tools 面板中,转到 Network 选项卡。
首先,勾选 Disable cache。
这将强制 assets 在无缓存的情况下加载,就像你第一次访问网站一样。
然后,我们需要一种限制带宽的方法。为此,点击带有 Online 值的下拉菜单。
在这里,我们可以选择各种限速值。遗憾的是,可用的选项并不适合这个项目。
要添加自定义值,点击 Add:
在应该会打开的面板中,点击 Add custom profile...,选择一个像 "Pretty fast" 这样的名称,并将 Download 值设置为 100000。
这个值的含义并不那么重要,只要我们有时间看到 assets 加载即可。对于一个典型的项目,你应该创建一个更低的值来模拟 ADSL 连接。
保存它,关闭面板,然后在下拉菜单中选择这个值。
现在网络会模拟一个相当快的带宽。
如果你重新加载,你会看到现在需要等待大约 3 秒钟,overlay 才会淡出。
创建 HTML 加载条
现在我们可以测试加载进度了,是时候添加加载条了。这一次,为了本课的目的,我们将用 HTML 添加这根条,但我们本可以像处理 overlay 那样创建一个带有 shader 并进行动画的新 plane。
打开 /src/index.html 文件,在 <canvas> 之后紧接着添加一个带有 loading-bar class 的 <div>:
<!-- ... -->
<body>
<canvas class="webgl"></canvas>
<div class="loading-bar"></div>
</body>
</html> 然后,在 /src/style.css 文件中,定位这根条,让它占据屏幕中间的整个宽度:
.loading-bar
{
position: absolute;
top: 50%;
width: 100%;
height: 2px;
background: #ffffff;
}
这是经典的 CSS,我们就不再解释了。
我们希望这根条从左侧开始填充。为此,我们将使用 transform CSS 属性对它进行缩放。
设置一个带有 scaleX 的 transform 属性,值为 0.3:
.loading-bar
{
/* ... */
transform: scaleX(0.3);
}
缩放生效了;遗憾的是,这根条似乎是从中间缩放的。要改变这一点,使用 transform-origin 属性:
.loading-bar
{
/* ... */
transform: scaleX(0.3);
transform-origin: top left;
}
这根条应该保持在左侧。
最后,将 scaleX 的值设置为 0:
.loading-bar
{
/* ... */
transform: scaleX(0);
transform-origin: top left;
}
更新这根条
现在我们有了这根条,让我们回到 /src/script.js,在加载进行时对它进行动画。
要知道加载何时进行,我们可以使用传入 LoadingManager 的第二个函数:
const loadingManager = new THREE.LoadingManager(
// ...
// Progress
() =>
{
console.log('progress')
}
) 这个函数会在每次加载完一个 asset 时被触发,并且可以接收 3 个参数:
- assets 的 URL
- 已加载了多少 assets
- 需要加载的 assets 总数
你可以测试这些值:
const loadingManager = new THREE.LoadingManager(
// ...
// Progress
(itemUrl, itemsLoaded, itemsTotal) =>
{
console.log(itemUrl, itemsLoaded, itemsTotal)
}
) 你应该会在日志中看到每个 asset 的加载情况。
现在,我们需要更新 scaleX 的值。为此,我们只需要更新 loading-bar 元素的样式。
首先,让我们获取那个元素。为此,我们将使用 document.querySelector:
const loadingBarElement = document.querySelector('.loading-bar') 这是经典的 JavaScript 技巧,用选择器获取一个元素——就像在 CSS 中一样。
在 progress 函数中,我们首先需要计算进度比例。这个值应该从没有 asset 加载时的 0 变化到所有 assets 都加载完成时的 1。
要计算这个比例,我们可以简单地将 itemsLoaded 除以 itemsTotal:
const loadingManager = new THREE.LoadingManager(
// ...
// Progress
(itemUrl, itemsLoaded, itemsTotal) =>
{
const progressRatio = itemsLoaded / itemsTotal
console.log(progressRatio)
}
) 你应该会在日志中看到 progressRatio 从 0 变化到 1。
我们现在可以更新 loadingBarElement 的 transform:
const loadingManager = new THREE.LoadingManager(
// ...
// Progress
(itemUrl, itemsLoaded, itemsTotal) =>
{
const progressRatio = itemsLoaded / itemsTotal
loadingBarElement.style.transform = `scaleX(${progressRatio})`
}
)
这根条应该会根据加载进度填充。
不要忘记保持 Developer tools 打开,勾选 Disable cache,并将限速设置为 Pretty fast。
平滑动画
让我们通过平滑过渡让动画更流畅一些。有很多方法可以做到这一点,但最简单的解决方案是在 CSS 中添加一个 transition。
在 /src/style.css 文件中,为 transform 属性添加一个 0.5s 过渡:
.loading-bar
{
/* ... */
transition: transform 0.5s;
}
你应该会得到相同的结果,但这根条上的动画更平滑。
隐藏这根条
有很多方法可以在所有内容加载完成后隐藏这根条,但为了让它更酷并保持流畅,我们将让这根条向右侧消失。
首先,在 /src/style.css 文件中,添加一个带有以下属性的新选择器:
.loading-bar.ended
{
transform: scaleX(0);
transform-origin: 100% 0;
transition: transform 1.5s ease-in-out;
} 请确保不要在 .loading-bar 和 .ended 之间添加空格,这样这些属性只有在元素同时具有这两个 class 时才会生效。
这些属性只是将这根条缩放到 0,并将变换对齐到右侧,同时使用 ease-in-out 时间函数进行更长的过渡。
我们现在需要做的就是在所有内容加载完成时给元素添加 ended class。
我们可以在 loaded 函数中使用 classList.add(...) 方法来完成:
const loadingManager = new THREE.LoadingManager(
// Loaded
() =>
{
gsap.to(overlayMaterial.uniforms.uAlpha, { duration: 3, value: 0, delay: 1 })
loadingBarElement.classList.add('ended')
},
// ...
) 遗憾的是,这不会生效,因为 progress 函数仍然对元素应用了 scaleX。
我们只需要将 transform 设置为空字符串即可解决:
const loadingManager = new THREE.LoadingManager(
// Loaded
() =>
{
gsap.to(overlayMaterial.uniforms.uAlpha, { duration: 3, value: 0, delay: 1 })
loadingBarElement.classList.add('ended')
loadingBarElement.style.transform = ''
},
// ...
)
这根条应该如预期那样向右侧消失。动画看起来有点跳跃,这并不理想。
这有两个原因。首先,第一次渲染 scene 中的元素需要时间,计算机会卡顿片刻。其次,我们给这根条添加了 0.5s 过渡。这意味着当 loaded 函数被触发时,这根条还没有完成到终点的过渡。
要解决这些问题,我们可以让 JavaScript 使用 setTimeout(...) 在开始开场动画之前等待 0.5s:
const loadingManager = new THREE.LoadingManager(
// Loaded
() =>
{
window.setTimeout(() =>
{
gsap.to(overlayMaterial.uniforms.uAlpha, { duration: 3, value: 0, delay: 1 })
loadingBarElement.classList.add('ended')
loadingBarElement.style.transform = ''
}, 500)
},
// ...
)
这根条现在消失得更平滑了。
就这样。你现在拥有了一个不太突兀的 intro,以及一个 HTML 加载条。